diff options
Diffstat (limited to 'src/pages/og/[slug].jpg.ts')
| -rw-r--r-- | src/pages/og/[slug].jpg.ts | 315 |
1 files changed, 315 insertions, 0 deletions
diff --git a/src/pages/og/[slug].jpg.ts b/src/pages/og/[slug].jpg.ts new file mode 100644 index 0000000..f2015c4 --- /dev/null +++ b/src/pages/og/[slug].jpg.ts @@ -0,0 +1,315 @@ +import satori from "satori"; +import { Resvg } from "@resvg/resvg-js"; +import sharp from "sharp"; +import { + readFileSync, + existsSync, + readdirSync, + writeFileSync, + mkdirSync, +} from "node:fs"; +import path from "node:path"; + +const contentDir = "src/content/docs"; + +const pages: Record<string, string> = { + index: "だれもがしあわせに暮らせるまちへ", + jisseki: "実績", + policy: "私の方針", + support: "ご支援", + contact: "コンタクト", + "whisper-to-ai-moji-okoshi": + "無料・超高精度のWhisperで文字起こしする方法(+α)", + "textlint-guide": "textlintで文章校正する方法", + "koubunsyo-kanri": "公文書管理の不正追及の軌跡", + "ijime-judai-jitai": "いじめ重大事態への対応の軌跡", + "fukushi-shisetsu-gyakutai": "障害者福祉施設における虐待通報対応の軌跡", + "aiki-kouen": "合気公園の軌跡", + "joutyo-koteikyu": "情緒固定級の軌跡", + "kajo-seigen-kanwa": "過剰な制限緩和の軌跡", + "saresio-kaihatu": "東京サレジオ学園北側開発問題の軌跡", + "vaccine-kyuusai-tekiseika": "ワクチン副反応救済制度の適正化の軌跡", + "dislexia-taiou": "ディスレクシア(読み書き障害)対応の軌跡", + "konan-eisei-kumiai": "湖南衛生組合の議員報酬・給与見直しの軌跡", + "ippan-situmon": "一般質問", + media: "メディア掲載", +}; + +function findMdxFiles(dir: string): string[] { + if (!existsSync(dir)) return []; + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) files.push(...findMdxFiles(full)); + else if (entry.name.endsWith(".mdx")) files.push(full); + } + return files; +} + +function readTitle(filePath: string): string | null { + try { + const content = readFileSync(filePath, "utf-8"); + const m = + content.match(/^title:\s*"(.+?)"/m) ?? content.match(/^title:\s*(.+)/m); + return m?.[1] ?? null; + } catch { + return null; + } +} + +export async function getStaticPaths() { + const slugs = new Set<string>(); + // Manual pages + for (const s of Object.keys(pages)) slugs.add(s); + // Auto-discover ippan-situmon / iinkai pages + for (const dir of ["ippan-situmon", "iinkai"]) { + for (const f of findMdxFiles(path.join(contentDir, dir))) { + const s = f + .replace(/\\/g, "/") + .replace(contentDir + "/", "") + .replace(/\.mdx$/, "") + .replace(/\/index$/, ""); + if (s) slugs.add(s); + } + } + return [...slugs] + .filter((s) => s.length > 0) + .map((slug) => ({ params: { slug: slug.replace(/\//g, "--") } })); +} + +const fontBuffer = readFileSync("node_modules/.noto-sans-jp.otf"); +const faceiconBuffer = readFileSync("public/img/faceicon.jpg"); +const faceiconBase64 = `data:image/jpeg;base64,${faceiconBuffer.toString("base64")}`; + +// Generate star elements for space-like background +const stars = [ + { x: 80, y: 40, r: 2, o: 0.8 }, + { x: 200, y: 90, r: 1.5, o: 0.6 }, + { x: 350, y: 30, r: 2.5, o: 0.9 }, + { x: 500, y: 80, r: 1, o: 0.5 }, + { x: 650, y: 50, r: 2, o: 0.7 }, + { x: 800, y: 100, r: 1.5, o: 0.6 }, + { x: 950, y: 40, r: 3, o: 0.85 }, + { x: 1100, y: 70, r: 1, o: 0.5 }, + { x: 1150, y: 120, r: 2, o: 0.7 }, + { x: 150, y: 160, r: 1, o: 0.4 }, + { x: 450, y: 140, r: 2, o: 0.5 }, + { x: 700, y: 160, r: 1.5, o: 0.45 }, + { x: 1000, y: 150, r: 2.5, o: 0.55 }, + { x: 50, y: 500, r: 1.5, o: 0.4 }, + { x: 300, y: 550, r: 2, o: 0.5 }, + { x: 600, y: 520, r: 1, o: 0.35 }, + { x: 850, y: 570, r: 2.5, o: 0.5 }, + { x: 1050, y: 540, r: 1, o: 0.4 }, + { x: 1150, y: 580, r: 1.5, o: 0.45 }, + { x: 120, y: 300, r: 1, o: 0.3 }, + { x: 1080, y: 280, r: 1.5, o: 0.35 }, +].map((s, i) => ({ + type: "div" as const, + props: { + key: `star-${i}`, + style: { + position: "absolute" as const, + left: `${s.x}px`, + top: `${s.y}px`, + width: `${s.r * 2}px`, + height: `${s.r * 2}px`, + borderRadius: "50%", + background: `rgba(255,255,255,${s.o})`, + boxShadow: `0 0 ${s.r * 3}px rgba(255,255,255,${s.o * 0.4})`, + }, + }, +})); + +export async function GET({ params }: { params: { slug: string } }) { + const slug = params.slug; + // まずそのままマッチ(koubunsyo-kanri などの手動ページ) + let title = pages[slug]; + if (!title) { + const fileSlug = slug.replace(/--/g, "/"); + title = pages[fileSlug]; + if (!title) { + title = + readTitle(path.join(contentDir, fileSlug + ".mdx")) ?? + readTitle(path.join(contentDir, fileSlug, "index.mdx")) ?? + "小平市議 安竹洋平 公式サイト"; + } + } + // 一般質問の月例会インデックスページ: 年度・月を補完 + const mm = slug.match(/^ippan-situmon--(r\d+d)--(\d+gatu)$/); + if (mm) { + const reiwa = mm[1].replace( + /^r(\d+)d$/, + (_, n) => `令和${n === "1" ? "元" : n}年`, + ); + const month = mm[2].replace(/(\d+)gatu/, "$1月"); + title = `${reiwa}${month}定例会`; + } + + // Check cache: if title hash matches, return cached JPEG from node_modules/.og-cache + const cacheDir = path.join(process.cwd(), "node_modules", ".og-cache"); + const cacheFile = path.join(cacheDir, `${slug}.jpg`); + const cacheJsonPath = path.join(cacheDir, ".og-cache.json"); + + let cache: Record<string, string> = {}; + if (existsSync(cacheJsonPath)) { + cache = JSON.parse(readFileSync(cacheJsonPath, "utf-8")); + } + + const { createHash } = await import("node:crypto"); + const titleHash = createHash("md5").update(title).digest("hex"); + + if (existsSync(cacheFile) && cache[slug] === titleHash) { + const cachedBuffer = readFileSync(cacheFile); + return new Response(cachedBuffer, { + headers: { + "Content-Type": "image/jpeg", + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); + } + + const svg = await satori( + { + type: "div", + props: { + style: { + display: "flex", + width: "1200px", + height: "630px", + background: + "linear-gradient(135deg, #0f0d2e 0%, #1e1b4b 30%, #3730a3 60%, #4f46e5 100%)", + color: "#ffffff", + fontFamily: "sans-serif", + padding: "64px 80px", + alignItems: "center", + gap: "56px", + position: "relative", + overflow: "hidden", + }, + children: [ + ...stars, + { + type: "img", + props: { + src: faceiconBase64, + width: 180, + height: 180, + style: { + borderRadius: "50%", + border: "3px solid rgba(255,255,255,0.25)", + flexShrink: "0", + position: "relative", + }, + }, + }, + { + type: "div", + props: { + style: { + display: "flex", + flexDirection: "column", + gap: "20px", + flex: "1", + position: "relative", + }, + children: [ + { + type: "div", + props: { + style: { + fontSize: "64px", + fontWeight: "700", + lineHeight: "1.2", + letterSpacing: "-0.02em", + }, + children: title, + }, + }, + { + type: "div", + props: { + style: { + display: "flex", + gap: "14px", + fontSize: "32px", + fontWeight: "500", + color: "#a5b4fc", + }, + children: [ + { type: "div", props: { children: "だれもが" } }, + { + type: "div", + props: { + style: { color: "#c7d2fe" }, + children: "しあわせに", + }, + }, + { + type: "div", + props: { + style: { color: "#e0e7ff" }, + children: "暮らせるまちへ", + }, + }, + ], + }, + }, + { + type: "div", + props: { + style: { + display: "flex", + gap: "16px", + fontSize: "26px", + fontWeight: "400", + color: "rgba(255,255,255,0.45)", + paddingTop: "12px", + borderTop: "1px solid rgba(255,255,255,0.1)", + }, + children: [ + { type: "div", props: { children: "小平市議 安竹洋平" } }, + { + type: "div", + props: { + style: { opacity: "0.6" }, + children: "yasutakeyohei.com", + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + } as any, + { + width: 1200, + height: 630, + fonts: [ + { name: "sans-serif", data: fontBuffer, weight: 400, style: "normal" }, + ], + }, + ); + + const resvg = new Resvg(svg, { fitTo: { mode: "width", value: 1200 } }); + const pngBuffer = resvg.render().asPng(); + // OGP は JPEG で出力する(PNGだと1枚300KBを超えるため、JPEGで約1/5にする) + const jpgBuffer = await sharp(pngBuffer).jpeg({ quality: 88 }).toBuffer(); + + // Save to cache in node_modules/.og-cache (persists across builds) + if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true }); + writeFileSync(cacheFile, jpgBuffer); + cache[slug] = titleHash; + writeFileSync(cacheJsonPath, JSON.stringify(cache, null, 2)); + + return new Response(jpgBuffer, { + headers: { + "Content-Type": "image/jpeg", + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); +} |
